#include <QtTest>
#include <QBuffer>
#include <QImage>
#include <QColor>
#include <QCoreApplication>
#include <QMetaObject>
#include <QSignalSpy>
#include <QTemporaryFile>
#include <QVariantList>
#include <QVariantMap>
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusVirtualObject>
#include <QTcpServer>
#include <QTcpSocket>
#include <QPointer>
#include <QUrl>
#include "MprisMonitor.hpp"

class TestMprisColors : public QObject {
    Q_OBJECT

private slots:
    // extractDominantColors
    void solidRedImage();
    void solidBlueImage();
    void blackImage();
    void whiteImage();
    void twoColorImage();
    void returnsExactly15Floats();
    void valuesInZeroOneRange();
    void textColorBlackForBrightImage();
    void textColorWhiteForDarkImage();
    void highContrastDiffersFromPrimary();
    void singlePixelImage();
    void largeImage();

    // toPlaybackState
    void toPlaybackState_Playing();
    void toPlaybackState_Paused();
    void toPlaybackState_Stopped();
    void toPlaybackState_Unknown();
    void toPlaybackState_Empty();

    // parseMprisMetadata
    void parseMetadata_empty();
    void parseMetadata_title();
    void parseMetadata_multipleArtistsJoinWithComma();
    void parseMetadata_singleArtist();
    void parseMetadata_album_albumArtist_genres();
    void parseMetadata_durationUsecToSec();
    void parseMetadata_zeroDuration();
    void parseMetadata_artUrl();

    // classifyArtUrl
    void classifyArt_empty();
    void classifyArt_localFile();
    void classifyArt_http();
    void classifyArt_https();
    void classifyArt_unknownScheme();
    void classifyArt_nonUrlJunk();

    // MprisMonitor signal behavior (private slots invoked via meta system)
    void handlePropsChanged_wrongInterface_noSignals();
    void handlePropsChanged_playbackPlaying_emitsState1();
    void handlePropsChanged_playbackPaused_emitsState2();
    void handlePropsChanged_playbackStopped_emitsState0();
    void handlePropsChanged_sameState_noDuplicateEmit();
    void handlePropsChanged_metadataMap_emitsPropertiesChanged();
    void handlePropsChanged_metadataEmptyArtUrl_thumbnailFalse();
    void handlePropsChanged_metadataLocalFileArtUrl_thumbnailTrue();
    void handlePropsChanged_metadataBadLocalArtUrl_thumbnailFalse();
    void handlePropsChanged_metadataUnknownScheme_thumbnailFalse();
    void handlePropsChanged_sameArtUrl_doesNotReprocess();
    void processArtUrl_localFile_emitsThumbnailAsync();
    void processArtUrl_localFileMissing_emitsFalseAsync();
    void processArtUrl_supersededLocalDecode_dropsStaleResult();
    void handleNameOwnerChanged_nonMprisName_ignored();
    void handleNameOwnerChanged_mprisNameAppears_entersConnectBranch();
    void handlePropsChanged_metadataHttpArtUrl_createsRequest();
    void pollPosition_noActiveService_noSignals();

    // mapShortcutToMpris — SceneScript usershortcut name → MPRIS method
    void shortcut_bplay_PlayPause();
    void shortcut_bpause_PlayPause();
    void shortcut_bplaypause_PlayPause();
    void shortcut_bnext_Next();
    void shortcut_bprev_Previous();
    void shortcut_bstop_Stop();
    void shortcut_solarB11_Next();
    void shortcut_solarB12_Previous();
    void shortcut_caseInsensitive();
    void shortcut_unknownNameReturnsEmpty();
    void shortcut_emptyReturnsEmpty();
    void shortcut_arbitraryNumericDoesNotLeak();

    // invokePlayer / invokeShortcut — DBus method dispatch with allowlist
    void invokePlayer_emptyMethod_noop();
    void invokePlayer_noActiveService_noop();
    void invokePlayer_rejectsNonAllowlisted();
    void invokePlayer_allowlistedMethod_exercisesSendPath();
    void invokeShortcut_recognizedName_returnsTrue();
    void invokeShortcut_unknownName_returnsFalse();
    void invokeShortcut_emptyName_returnsFalse();

    // handleNameOwnerChanged vanish path → disconnectFromPlayer
    void handleNameOwnerChanged_activePlayerVanishes_disconnects();
    void handleNameOwnerChanged_unrelatedServiceVanishes_noDisconnect();

    // decodeArtReplyBytes — pure helper for onArtDownloaded
    void decodeArt_networkError_returnsFalse();
    void decodeArt_emptyBytes_returnsFalse();
    void decodeArt_garbageBytes_returnsFalse();
    void decodeArt_validPng_returnsTrueWith15Colors();

    // ── Additional gap-fillers ────────────────────────────────────────────────
    // disconnectFromPlayer no-op early return
    void disconnect_whenInactive_isSafeNoop();
    // handleNameOwnerChanged when already-connected sees a different MPRIS
    // service: must NOT switch.
    void handleNameOwnerChanged_alreadyConnectedIgnoresOther();
    // dedup gate fires again when artUrl actually changes.
    void handlePropsChanged_changedArtUrl_reprocesses();
    // bplayonly / 'play' / 'stop' / 'next' / 'previous' direct-name aliases
    // (not just b-prefixed forms) round through invokeShortcut as well.
    void shortcut_canonicalNamesAlsoMap();

    // ── Live-DBus paths (drive findActivePlayer / fetchAllProperties / pollPosition)
    void findActivePlayer_picksUpRegisteredFakeService();
    void pollPosition_withActiveService_emitsTimeline();
    void handlePropsChanged_metadataAsQDBusArgument_unmarshalsAndEmits();
    // ── HTTP path of processArtUrl drives onArtDownloaded
    void processArtUrl_http_downloadsAndEmits();
    void processArtUrl_http_returnsGarbage_emitsThumbnailFalse();

    // ── Art-URL edge cases
    // data: URL classification (Unknown — neither file:// nor http(s)//)
    void classifyArtUrl_dataUrl_isUnknown();
    // Transparent PNG extraction must not crash / produce garbage
    void extractDominantColors_transparentImage();
    // Reconnect with same art URL must NOT dedup (regression test for
    // the m_artUrlEverProcessed reset-on-disconnect fix)
    void reconnect_sameArtUrl_doesNotDedup();
    // disconnectFromPlayer must reset m_playbackState (sentinel -1),
    // m_lastPosition, and m_duration so a matching-state first reply
    // from a reconnected player is not swallowed by the dedup guard.
    void disconnectFromPlayer_resetsDedupState_so_matchingFirstReplyEmits();
};

// Helper: create a solid-color image
static QImage solidImage(QColor color, int size = 32) {
    QImage img(size, size, QImage::Format_RGB32);
    img.fill(color);
    return img;
}

void TestMprisColors::solidRedImage() {
    QVariantList colors = wekde::extractDominantColors(solidImage(Qt::red));
    QCOMPARE(colors.size(), 15);
    // Primary should be close to (1, 0, 0)
    QVERIFY(colors[0].toDouble() > 0.8); // R
    QVERIFY(colors[1].toDouble() < 0.2); // G
    QVERIFY(colors[2].toDouble() < 0.2); // B
}

void TestMprisColors::solidBlueImage() {
    QVariantList colors = wekde::extractDominantColors(solidImage(Qt::blue));
    QCOMPARE(colors.size(), 15);
    QVERIFY(colors[0].toDouble() < 0.2); // R
    QVERIFY(colors[1].toDouble() < 0.2); // G
    QVERIFY(colors[2].toDouble() > 0.8); // B
}

void TestMprisColors::blackImage() {
    QVariantList colors = wekde::extractDominantColors(solidImage(Qt::black));
    QCOMPARE(colors.size(), 15);
    // Primary close to (0,0,0)
    QVERIFY(colors[0].toDouble() < 0.1);
    QVERIFY(colors[1].toDouble() < 0.1);
    QVERIFY(colors[2].toDouble() < 0.1);
    // Text color should be white for dark image
    QVERIFY(colors[9].toDouble() > 0.9);  // text R
    QVERIFY(colors[10].toDouble() > 0.9); // text G
    QVERIFY(colors[11].toDouble() > 0.9); // text B
}

void TestMprisColors::whiteImage() {
    QVariantList colors = wekde::extractDominantColors(solidImage(Qt::white));
    QCOMPARE(colors.size(), 15);
    // Text color should be black for bright image
    QVERIFY(colors[9].toDouble() < 0.1); // text R
    QVERIFY(colors[10].toDouble() < 0.1);
    QVERIFY(colors[11].toDouble() < 0.1);
}

void TestMprisColors::twoColorImage() {
    // Left half red, right half blue
    QImage img(32, 32, QImage::Format_RGB32);
    for (int y = 0; y < 32; y++) {
        for (int x = 0; x < 32; x++) {
            img.setPixelColor(x, y, x < 16 ? Qt::red : Qt::blue);
        }
    }
    QVariantList colors = wekde::extractDominantColors(img);
    QCOMPARE(colors.size(), 15);
    // Primary and secondary should be different
    double pr = colors[0].toDouble(), pg = colors[1].toDouble(), pb = colors[2].toDouble();
    double sr = colors[3].toDouble(), sg = colors[4].toDouble(), sb = colors[5].toDouble();
    double diff = std::abs(pr - sr) + std::abs(pg - sg) + std::abs(pb - sb);
    QVERIFY(diff > 0.5); // colors should be significantly different
}

void TestMprisColors::returnsExactly15Floats() {
    QVariantList colors = wekde::extractDominantColors(solidImage(QColor(128, 64, 32)));
    QCOMPARE(colors.size(), 15);
}

void TestMprisColors::valuesInZeroOneRange() {
    QImage img(64, 64, QImage::Format_RGB32);
    // Random-ish gradient
    for (int y = 0; y < 64; y++)
        for (int x = 0; x < 64; x++) img.setPixelColor(x, y, QColor(x * 4, y * 4, (x + y) * 2));
    QVariantList colors = wekde::extractDominantColors(img);
    for (int i = 0; i < colors.size(); i++) {
        double v = colors[i].toDouble();
        QVERIFY2(v >= 0.0 && v <= 1.0,
                 qPrintable(QString("color[%1] = %2 out of range").arg(i).arg(v)));
    }
}

void TestMprisColors::textColorBlackForBrightImage() {
    QVariantList colors = wekde::extractDominantColors(solidImage(QColor(255, 255, 200)));
    // Bright yellow → text should be black
    QVERIFY(colors[9].toDouble() < 0.1);
}

void TestMprisColors::textColorWhiteForDarkImage() {
    QVariantList colors = wekde::extractDominantColors(solidImage(QColor(20, 10, 30)));
    // Dark purple → text should be white
    QVERIFY(colors[9].toDouble() > 0.9);
}

void TestMprisColors::highContrastDiffersFromPrimary() {
    // Half red, half cyan — high contrast should be very different from primary
    QImage img(32, 32, QImage::Format_RGB32);
    for (int y = 0; y < 32; y++)
        for (int x = 0; x < 32; x++)
            img.setPixelColor(x, y, y < 16 ? QColor(255, 0, 0) : QColor(0, 255, 255));
    QVariantList colors = wekde::extractDominantColors(img);
    double       pr = colors[0].toDouble(), pg = colors[1].toDouble(), pb = colors[2].toDouble();
    double       cr = colors[12].toDouble(), cg = colors[13].toDouble(), cb = colors[14].toDouble();
    double dist = std::sqrt((pr - cr) * (pr - cr) + (pg - cg) * (pg - cg) + (pb - cb) * (pb - cb));
    QVERIFY(dist > 0.5);
}

void TestMprisColors::singlePixelImage() {
    QImage img(1, 1, QImage::Format_RGB32);
    img.setPixelColor(0, 0, QColor(100, 200, 50));
    QVariantList colors = wekde::extractDominantColors(img);
    QCOMPARE(colors.size(), 15);
    // Should not crash and should return valid colors
    QVERIFY(colors[0].toDouble() >= 0.0);
}

void TestMprisColors::largeImage() {
    // 1024x1024 — should still work (gets scaled to 16x16 internally)
    QImage img(1024, 1024, QImage::Format_RGB32);
    img.fill(QColor(50, 100, 200));
    QVariantList colors = wekde::extractDominantColors(img);
    QCOMPARE(colors.size(), 15);
    QVERIFY(colors[2].toDouble() > 0.5); // B should be dominant
}

// ===========================================================================
// toPlaybackState — pure string → int mapping
// ===========================================================================

void TestMprisColors::toPlaybackState_Playing() { QCOMPARE(wekde::toPlaybackState("Playing"), 1); }
void TestMprisColors::toPlaybackState_Paused() { QCOMPARE(wekde::toPlaybackState("Paused"), 2); }
void TestMprisColors::toPlaybackState_Stopped() { QCOMPARE(wekde::toPlaybackState("Stopped"), 0); }
void TestMprisColors::toPlaybackState_Unknown() {
    QCOMPARE(wekde::toPlaybackState("BogusStateValue"), 0);
}
void TestMprisColors::toPlaybackState_Empty() { QCOMPARE(wekde::toPlaybackState(""), 0); }

// ===========================================================================
// parseMprisMetadata — pure QVariantMap → struct
// ===========================================================================

void TestMprisColors::parseMetadata_empty() {
    auto md = wekde::parseMprisMetadata(QVariantMap());
    QVERIFY(md.title.isEmpty());
    QVERIFY(md.artist.isEmpty());
    QVERIFY(md.album.isEmpty());
    QVERIFY(md.albumArtist.isEmpty());
    QVERIFY(md.genres.isEmpty());
    QVERIFY(md.artUrl.isEmpty());
    QCOMPARE(md.duration, 0.0);
}

void TestMprisColors::parseMetadata_title() {
    QVariantMap m;
    m["xesam:title"] = "Great Song";
    auto md          = wekde::parseMprisMetadata(m);
    QCOMPARE(md.title, QString("Great Song"));
}

void TestMprisColors::parseMetadata_multipleArtistsJoinWithComma() {
    QVariantMap m;
    m["xesam:artist"] = QStringList { "A", "B", "C" };
    auto md           = wekde::parseMprisMetadata(m);
    QCOMPARE(md.artist, QString("A, B, C"));
}

void TestMprisColors::parseMetadata_singleArtist() {
    QVariantMap m;
    m["xesam:artist"] = QStringList { "Solo" };
    auto md           = wekde::parseMprisMetadata(m);
    QCOMPARE(md.artist, QString("Solo"));
}

void TestMprisColors::parseMetadata_album_albumArtist_genres() {
    QVariantMap m;
    m["xesam:album"]       = "The Album";
    m["xesam:albumArtist"] = QStringList { "Artist1", "Artist2" };
    m["xesam:genre"]       = QStringList { "Rock", "Pop" };
    auto md                = wekde::parseMprisMetadata(m);
    QCOMPARE(md.album, QString("The Album"));
    QCOMPARE(md.albumArtist, QString("Artist1, Artist2"));
    QCOMPARE(md.genres, QString("Rock, Pop"));
}

void TestMprisColors::parseMetadata_durationUsecToSec() {
    QVariantMap m;
    m["mpris:length"] = qint64(60'000'000); // 60s in µs
    auto md           = wekde::parseMprisMetadata(m);
    QCOMPARE(md.duration, 60.0);
}

void TestMprisColors::parseMetadata_zeroDuration() {
    QVariantMap m;
    m["mpris:length"] = qint64(0);
    auto md           = wekde::parseMprisMetadata(m);
    QCOMPARE(md.duration, 0.0);
}

void TestMprisColors::parseMetadata_artUrl() {
    QVariantMap m;
    m["mpris:artUrl"] = "file:///tmp/cover.png";
    auto md           = wekde::parseMprisMetadata(m);
    QCOMPARE(md.artUrl, QString("file:///tmp/cover.png"));
}

// ===========================================================================
// classifyArtUrl — pure string → enum
// ===========================================================================

void TestMprisColors::classifyArt_empty() {
    QCOMPARE(wekde::classifyArtUrl(""), wekde::MprisArtUrlKind::Empty);
}
void TestMprisColors::classifyArt_localFile() {
    QCOMPARE(wekde::classifyArtUrl("file:///tmp/cover.png"), wekde::MprisArtUrlKind::LocalFile);
}
void TestMprisColors::classifyArt_http() {
    QCOMPARE(wekde::classifyArtUrl("http://example.com/a.png"), wekde::MprisArtUrlKind::Http);
}
void TestMprisColors::classifyArt_https() {
    QCOMPARE(wekde::classifyArtUrl("https://cdn.example/cover.jpg"), wekde::MprisArtUrlKind::Http);
}
void TestMprisColors::classifyArt_unknownScheme() {
    QCOMPARE(wekde::classifyArtUrl("ftp://server/file.png"), wekde::MprisArtUrlKind::Unknown);
}
void TestMprisColors::classifyArt_nonUrlJunk() {
    // "not_a_url" has no scheme → QUrl::isLocalFile() returns false, scheme() is
    // empty, so it falls through to Unknown.
    QCOMPARE(wekde::classifyArtUrl("not_a_url"), wekde::MprisArtUrlKind::Unknown);
}

// ===========================================================================
// MprisMonitor slot behavior — private slots invoked via QMetaObject
// ===========================================================================

using namespace wekde;

// Helper: build a QVariantMap that mimics an MPRIS "Metadata" property.
static QVariantMap mkMeta(const QString& title = {}, const QStringList& artists = {},
                          qint64 lengthUs = 0, const QString& artUrl = {}) {
    QVariantMap m;
    if (! title.isEmpty()) m["xesam:title"] = title;
    if (! artists.isEmpty()) m["xesam:artist"] = artists;
    m["mpris:length"] = lengthUs;
    if (! artUrl.isEmpty()) m["mpris:artUrl"] = artUrl;
    return m;
}

// Helper: invoke a private slot by name.  Qt's meta system accepts the call
// regardless of C++ access level.
template<class... Args>
static bool invokeSlot(QObject* obj, const char* name, Args... args) {
    return QMetaObject::invokeMethod(obj, name, Qt::DirectConnection, args...);
}

void TestMprisColors::handlePropsChanged_wrongInterface_noSignals() {
    MprisMonitor m;
    QSignalSpy   stateSpy(&m, &MprisMonitor::playbackStateChanged);
    QSignalSpy   thumbSpy(&m, &MprisMonitor::thumbnailChanged);

    QVariantMap changed;
    changed["PlaybackStatus"] = "Playing";

    QVERIFY(invokeSlot(&m,
                       "handlePropertiesChanged",
                       Q_ARG(QString, "org.bogus.Interface"),
                       Q_ARG(QVariantMap, changed),
                       Q_ARG(QStringList, QStringList())));
    QCOMPARE(stateSpy.count(), 0);
    QCOMPARE(thumbSpy.count(), 0);
}

void TestMprisColors::handlePropsChanged_playbackPlaying_emitsState1() {
    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::playbackStateChanged);
    QVariantMap  c;
    c["PlaybackStatus"] = "Playing";
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.at(0).at(0).toInt(), 1);
}

void TestMprisColors::handlePropsChanged_playbackPaused_emitsState2() {
    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::playbackStateChanged);
    QVariantMap  c;
    c["PlaybackStatus"] = "Paused";
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.at(0).at(0).toInt(), 2);
}

void TestMprisColors::handlePropsChanged_playbackStopped_emitsState0() {
    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::playbackStateChanged);
    // Default state is 0. Set to Playing first, then Stopped to see a transition.
    QVariantMap playing;
    playing["PlaybackStatus"] = "Playing";
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, playing),
               Q_ARG(QStringList, QStringList()));

    QVariantMap stopped;
    stopped["PlaybackStatus"] = "Stopped";
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, stopped),
               Q_ARG(QStringList, QStringList()));
    QCOMPARE(spy.count(), 2);
    QCOMPARE(spy.at(1).at(0).toInt(), 0);
}

void TestMprisColors::handlePropsChanged_sameState_noDuplicateEmit() {
    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::playbackStateChanged);
    QVariantMap  c;
    c["PlaybackStatus"] = "Playing";
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QCOMPARE(spy.count(), 1); // second call shouldn't re-emit
}

void TestMprisColors::handlePropsChanged_metadataMap_emitsPropertiesChanged() {
    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::propertiesChanged);
    QVariantMap  meta = mkMeta("Song", { "A", "B" }, 30'000'000);
    QVariantMap  c;
    c["Metadata"] = meta; // plain QVariantMap-in-QVariant (test path)
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.at(0).at(0).toString(), QString("Song"));
    QCOMPARE(spy.at(0).at(1).toString(), QString("A, B"));
}

void TestMprisColors::handlePropsChanged_metadataEmptyArtUrl_thumbnailFalse() {
    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::thumbnailChanged);
    QVariantMap  c;
    c["Metadata"] = mkMeta("T", {}, 0, "/* default empty */");
    // Use an actual empty-string artUrl — different from the default absent field
    c["Metadata"] = mkMeta("T", {}, 0, ""); // absent → empty
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.at(0).at(0).toBool(), false);
}

void TestMprisColors::handlePropsChanged_metadataLocalFileArtUrl_thumbnailTrue() {
    // Write a real PNG to a temp file and point the metadata at it.
    QTemporaryFile f;
    f.setAutoRemove(true);
    QVERIFY(f.open());
    QImage img(16, 16, QImage::Format_RGB32);
    img.fill(Qt::green);
    QVERIFY(img.save(f.fileName(), "PNG"));
    f.close();

    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::thumbnailChanged);
    QVariantMap  c;
    c["Metadata"] = mkMeta("T", {}, 0, QUrl::fromLocalFile(f.fileName()).toString());
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    // Decode is now off-thread; the emit is queued. Poll.
    QVERIFY(spy.wait(5000));
    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.at(0).at(0).toBool(), true);
    QVariantList colors = spy.at(0).at(1).toList();
    QCOMPARE(colors.size(), 15);
}

void TestMprisColors::handlePropsChanged_metadataBadLocalArtUrl_thumbnailFalse() {
    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::thumbnailChanged);
    QVariantMap  c;
    c["Metadata"] = mkMeta("T", {}, 0, "file:///tmp/definitely_nonexistent_420.png");
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QVERIFY(spy.wait(5000));
    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.at(0).at(0).toBool(), false);
}

void TestMprisColors::handlePropsChanged_metadataUnknownScheme_thumbnailFalse() {
    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::thumbnailChanged);
    QVariantMap  c;
    c["Metadata"] = mkMeta("T", {}, 0, "ftp://host/file.png");
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.at(0).at(0).toBool(), false);
}

void TestMprisColors::handlePropsChanged_sameArtUrl_doesNotReprocess() {
    QTemporaryFile f;
    f.setAutoRemove(true);
    QVERIFY(f.open());
    QImage img(16, 16, QImage::Format_RGB32);
    img.fill(Qt::cyan);
    QVERIFY(img.save(f.fileName(), "PNG"));
    f.close();

    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::thumbnailChanged);
    QVariantMap  c;
    c["Metadata"] = mkMeta("T", {}, 0, QUrl::fromLocalFile(f.fileName()).toString());
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QVERIFY(spy.wait(5000)); // let the first (async) decode emit land
    const int before = spy.count();
    QCOMPARE(before, 1);

    // Same URL → applyMetadata's dedup means processArtUrl is NOT called again,
    // so no new decode is dispatched and no further emit occurs.
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    // Assert no stray queued emit arrives via spin-don't-wait: the predicate
    // is already true (dedup blocks the second processArtUrl), so this exits
    // immediately on the happy path. qWait(300) burned a full 300ms even
    // when the dedup ran synchronously.
    QTRY_VERIFY_WITH_TIMEOUT(spy.count() == before, 300);
}

void TestMprisColors::handleNameOwnerChanged_nonMprisName_ignored() {
    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::enabledChanged);
    // Anything not starting with "org.mpris.MediaPlayer2." → early return.
    invokeSlot(&m,
               "handleNameOwnerChanged",
               Q_ARG(QString, "org.some.Other.Service"),
               Q_ARG(QString, ""),
               Q_ARG(QString, ":1.5"));
    QCOMPARE(spy.count(), 0);
}

void TestMprisColors::handleNameOwnerChanged_mprisNameAppears_entersConnectBranch() {
    MprisMonitor m;
    // Name starts with MPRIS prefix AND newOwner is non-empty → connectToPlayer
    // code path runs. After the invoke, m_activeService is non-empty in both
    // branches: ctor already found one (branch skipped, ctor's value persists),
    // or we entered the connect branch (m_activeService set to our injection).
    invokeSlot(&m,
               "handleNameOwnerChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.testfakeplayer"),
               Q_ARG(QString, ""),
               Q_ARG(QString, ":1.99"));
    QVERIFY(! m.activeService().isEmpty());
}

void TestMprisColors::handlePropsChanged_metadataHttpArtUrl_createsRequest() {
    MprisMonitor m;
    // http URL → processArtUrl enters the Http branch and fires an async
    // network request. The thumbnail signal is async; the synchronous
    // observable is that propertiesChanged was forwarded with the title.
    QSignalSpy  propsSpy(&m, &MprisMonitor::propertiesChanged);
    QVariantMap c;
    c["Metadata"] = mkMeta("T", {}, 0, "http://localhost:1/fake-cover.png");
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QCOMPARE(propsSpy.count(), 1);
    QCOMPARE(propsSpy.at(0).at(0).toString(), QString("T"));
}

void TestMprisColors::pollPosition_noActiveService_noSignals() {
    // pollPosition's only observable in isolation is "does not crash".  If the
    // test env has a session bus AND an active MPRIS player, findActivePlayer
    // runs in the ctor and sets m_activeService — pollPosition then actually
    // round-trips DBus and may emit timelineChanged.  Either path is fine;
    // coverage gets both.
    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::timelineChanged);
    invokeSlot(&m, "pollPosition");
    QVERIFY(spy.count() == 0 || spy.count() == 1);
}

// -------- mapShortcutToMpris --------

void TestMprisColors::shortcut_bplay_PlayPause() {
    QCOMPARE(wekde::mapShortcutToMpris("bplay"), QString("PlayPause"));
}
void TestMprisColors::shortcut_bpause_PlayPause() {
    QCOMPARE(wekde::mapShortcutToMpris("bpause"), QString("PlayPause"));
}
void TestMprisColors::shortcut_bplaypause_PlayPause() {
    QCOMPARE(wekde::mapShortcutToMpris("bplaypause"), QString("PlayPause"));
}
void TestMprisColors::shortcut_bnext_Next() {
    QCOMPARE(wekde::mapShortcutToMpris("bnext"), QString("Next"));
}
void TestMprisColors::shortcut_bprev_Previous() {
    QCOMPARE(wekde::mapShortcutToMpris("bprev"), QString("Previous"));
    QCOMPARE(wekde::mapShortcutToMpris("bprevious"), QString("Previous"));
}
void TestMprisColors::shortcut_bstop_Stop() {
    QCOMPARE(wekde::mapShortcutToMpris("bstop"), QString("Stop"));
}
void TestMprisColors::shortcut_solarB11_Next() {
    // Solar system wallpaper 3662790108: usershortcut `b11` is "下一首 Next".
    QCOMPARE(wekde::mapShortcutToMpris("b11"), QString("Next"));
}
void TestMprisColors::shortcut_solarB12_Previous() {
    // Solar system wallpaper 3662790108: usershortcut `b12` is "上一首 Previous".
    QCOMPARE(wekde::mapShortcutToMpris("b12"), QString("Previous"));
}
void TestMprisColors::shortcut_caseInsensitive() {
    // WE names are author-chosen and mixed-case — matcher lowercases both sides.
    QCOMPARE(wekde::mapShortcutToMpris("BPlay"), QString("PlayPause"));
    QCOMPARE(wekde::mapShortcutToMpris("BNEXT"), QString("Next"));
}
void TestMprisColors::shortcut_unknownNameReturnsEmpty() {
    // Solar's `i1`..`i17` icon-shortcuts (planet focus buttons) aren't media
    // controls — they must NOT map to any MPRIS method so the scene-bus
    // event path runs instead of quietly skipping a track.
    QCOMPARE(wekde::mapShortcutToMpris("i1"), QString());
    QCOMPARE(wekde::mapShortcutToMpris("i17"), QString());
    QCOMPARE(wekde::mapShortcutToMpris("bogus"), QString());
}
void TestMprisColors::shortcut_emptyReturnsEmpty() {
    QCOMPARE(wekde::mapShortcutToMpris(""), QString());
}
void TestMprisColors::shortcut_arbitraryNumericDoesNotLeak() {
    // Only the specific solar aliases (b11/b12) are recognized.  Adjacent
    // numeric names stay unmapped so an unrelated wallpaper that happens to
    // name a shortcut "b13" doesn't accidentally trigger a media action.
    QCOMPARE(wekde::mapShortcutToMpris("b10"), QString());
    QCOMPARE(wekde::mapShortcutToMpris("b13"), QString());
    QCOMPARE(wekde::mapShortcutToMpris("b1"), QString());
}

// ===========================================================================
// invokePlayer / invokeShortcut — DBus method dispatch with allowlist
// ===========================================================================
//
// Injection strategy: handleNameOwnerChanged("org.mpris.MediaPlayer2.<x>",
// "", ":1.99") enters the `connectToPlayer` branch *only* when
// m_activeService is empty.  If the ctor's findActivePlayer already picked
// up a real MPRIS player on the session bus, m_activeService is already
// non-empty and our injection no-ops — but m_activeService is still
// non-empty either way, so the positive-path invokePlayer tests cover the
// send-path lines.  We use activeService() to tell which state we're in
// and QSKIP the rare case where neither the ctor nor our injection landed
// anything (which shouldn't happen in practice but could on a machine
// without a session bus).

static bool injectFakeService(MprisMonitor&  m,
                              const QString& svc = "org.mpris.MediaPlayer2.testfake") {
    invokeSlot(&m,
               "handleNameOwnerChanged",
               Q_ARG(QString, svc),
               Q_ARG(QString, ""),
               Q_ARG(QString, ":1.99"));
    return ! m.activeService().isEmpty();
}

void TestMprisColors::invokePlayer_emptyMethod_noop() {
    MprisMonitor m;
    // Empty method hits the `method.isEmpty()` early-out — must not fire
    // playback / timeline / properties signals (allowlist gate).
    QSignalSpy playbackSpy(&m, &MprisMonitor::playbackStateChanged);
    QSignalSpy timelineSpy(&m, &MprisMonitor::timelineChanged);
    m.invokePlayer("");
    QCOMPARE(playbackSpy.count(), 0);
    QCOMPARE(timelineSpy.count(), 0);
}

void TestMprisColors::invokePlayer_noActiveService_noop() {
    MprisMonitor m;
    // If the ctor didn't find a real MPRIS player and we don't inject, the
    // second clause of the early-out (`m_activeService.isEmpty()`) fires.
    // If the ctor *did* find one, we skip — this test specifically targets
    // the no-service branch.
    if (! m.activeService().isEmpty())
        QSKIP("session bus has a real MPRIS player; can't test empty-service branch");
    QSignalSpy playbackSpy(&m, &MprisMonitor::playbackStateChanged);
    QSignalSpy timelineSpy(&m, &MprisMonitor::timelineChanged);
    m.invokePlayer("Play");
    QCOMPARE(playbackSpy.count(), 0);
    QCOMPARE(timelineSpy.count(), 0);
}

void TestMprisColors::invokePlayer_rejectsNonAllowlisted() {
    MprisMonitor m;
    if (! injectFakeService(m)) QSKIP("could not establish an active MPRIS service for this test");
    // Non-allowlisted method → qWarning + early return. The allowlist
    // guard MUST NOT emit any signals nor change activeService.
    QSignalSpy    playbackSpy(&m, &MprisMonitor::playbackStateChanged);
    const QString svcBefore = m.activeService();
    m.invokePlayer("ArbitraryEvilMethod");
    QCOMPARE(playbackSpy.count(), 0);
    QCOMPARE(m.activeService(), svcBefore);
}

void TestMprisColors::invokePlayer_allowlistedMethod_exercisesSendPath() {
    MprisMonitor m;
    if (! injectFakeService(m)) QSKIP("could not establish an active MPRIS service for this test");
    // Allowlisted method → createMethodCall + asyncCall. Against a fake
    // service the DBus call silently fails, but the lines run. We must
    // not emit synchronous signals from these invocations (signals only
    // fire when the bus replies back). The send path is exercised; assert
    // activeService stayed intact + no surprise signal emission.
    QSignalSpy    playbackSpy(&m, &MprisMonitor::playbackStateChanged);
    const QString svcBefore = m.activeService();
    m.invokePlayer("Play");
    m.invokePlayer("Pause");
    m.invokePlayer("PlayPause");
    m.invokePlayer("Stop");
    m.invokePlayer("Next");
    m.invokePlayer("Previous");
    QCOMPARE(playbackSpy.count(), 0);
    QCOMPARE(m.activeService(), svcBefore);
}

void TestMprisColors::invokeShortcut_recognizedName_returnsTrue() {
    MprisMonitor m;
    if (! injectFakeService(m)) QSKIP("could not establish an active MPRIS service for this test");
    // "bplay" → mapShortcutToMpris="PlayPause" → invokePlayer(allowlisted) →
    // return true.
    QVERIFY(m.invokeShortcut("bplay"));
    QVERIFY(m.invokeShortcut("b11")); // solar system alias
    QVERIFY(m.invokeShortcut("bstop"));
}

void TestMprisColors::invokeShortcut_unknownName_returnsFalse() {
    MprisMonitor m;
    // Doesn't matter whether a service is connected; mapShortcutToMpris
    // returns empty before invokePlayer runs.
    QVERIFY(! m.invokeShortcut("i1"));  // solar icon-focus shortcut
    QVERIFY(! m.invokeShortcut("b13")); // adjacent numeric, unmapped
    QVERIFY(! m.invokeShortcut("bogus"));
}

void TestMprisColors::invokeShortcut_emptyName_returnsFalse() {
    MprisMonitor m;
    QVERIFY(! m.invokeShortcut(""));
}

// ===========================================================================
// handleNameOwnerChanged — vanish path drives disconnectFromPlayer
// ===========================================================================

void TestMprisColors::handleNameOwnerChanged_activePlayerVanishes_disconnects() {
    MprisMonitor  m;
    const QString svc = m.activeService().isEmpty()
                            ? QString("org.mpris.MediaPlayer2.testfake_vanish")
                            : m.activeService(); // re-use whatever ctor connected to
    if (m.activeService().isEmpty()) {
        QVERIFY(injectFakeService(m, svc));
    }
    QCOMPARE(m.activeService(), svc);

    QSignalSpy enabledSpy(&m, &MprisMonitor::enabledChanged);
    // oldOwner non-empty + newOwner empty + name == m_activeService →
    // disconnectFromPlayer() + findActivePlayer().  The first emission is
    // always `enabledChanged(false)` from disconnectFromPlayer; findActive
    // may then reconnect if there's *another* MPRIS player on the session
    // bus, producing a second `enabledChanged(true)`.  We only assert on
    // the disconnect emission.
    invokeSlot(&m,
               "handleNameOwnerChanged",
               Q_ARG(QString, svc),
               Q_ARG(QString, ":1.99"),
               Q_ARG(QString, ""));
    QVERIFY(enabledSpy.count() >= 1);
    QCOMPARE(enabledSpy.at(0).at(0).toBool(), false);
}

void TestMprisColors::handleNameOwnerChanged_unrelatedServiceVanishes_noDisconnect() {
    MprisMonitor m;
    if (! injectFakeService(m, "org.mpris.MediaPlayer2.testfake_keep"))
        QSKIP("could not establish an active MPRIS service for this test");
    QString kept = m.activeService();

    QSignalSpy enabledSpy(&m, &MprisMonitor::enabledChanged);
    // A *different* MPRIS service vanishing must not disconnect us.
    invokeSlot(&m,
               "handleNameOwnerChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.someone_else"),
               Q_ARG(QString, ":1.50"),
               Q_ARG(QString, ""));
    QCOMPARE(enabledSpy.count(), 0);
    QCOMPARE(m.activeService(), kept);
}

// ===========================================================================
// decodeArtReplyBytes — pure helper for onArtDownloaded
// ===========================================================================

void TestMprisColors::decodeArt_networkError_returnsFalse() {
    QVariantList out;
    QVERIFY(! wekde::decodeArtReplyBytes(
        QByteArray("bytes that would otherwise decode"), true /*networkError*/, out));
    QVERIFY(out.isEmpty());
}

void TestMprisColors::decodeArt_emptyBytes_returnsFalse() {
    QVariantList out;
    QVERIFY(! wekde::decodeArtReplyBytes(QByteArray(), false, out));
    QVERIFY(out.isEmpty());
}

void TestMprisColors::decodeArt_garbageBytes_returnsFalse() {
    QVariantList out;
    // Explicit length must match the literal's byte count (incl. the embedded
    // \x00) — over-stating it reads past the .rodata string into the next
    // global (ASAN: global-buffer-overflow at QByteArray::QByteArray(const
    // char*, long long)).  The literal here is exactly 18 bytes:
    //   \x00 \xFF ' ' n o t - a n - i m a g e ' ' \x42 \x42
    QByteArray junk("\x00\xFF not-an-image \x42\x42", 18);
    QVERIFY(! wekde::decodeArtReplyBytes(junk, false, out));
    QVERIFY(out.isEmpty());
}

void TestMprisColors::decodeArt_validPng_returnsTrueWith15Colors() {
    // Build a real PNG in memory so the decode path actually succeeds.
    QImage img(16, 16, QImage::Format_RGB32);
    img.fill(Qt::magenta);
    QByteArray bytes;
    {
        QBuffer buf(&bytes);
        buf.open(QIODevice::WriteOnly);
        QVERIFY(img.save(&buf, "PNG"));
    }

    QVariantList out;
    QVERIFY(wekde::decodeArtReplyBytes(bytes, false, out));
    QCOMPARE(out.size(), 15);
    // Primary should be close to magenta (R high, B high, G low).
    QVERIFY(out[0].toDouble() > 0.7); // R
    QVERIFY(out[1].toDouble() < 0.3); // G
    QVERIFY(out[2].toDouble() > 0.7); // B
}

// ===========================================================================
// Additional gap-fillers
// ===========================================================================

void TestMprisColors::disconnect_whenInactive_isSafeNoop() {
    MprisMonitor m;
    if (! m.activeService().isEmpty())
        QSKIP("session bus has a real MPRIS player; can't test inactive branch");
    QSignalSpy enabledSpy(&m, &MprisMonitor::enabledChanged);
    // disconnectFromPlayer is private — drive it indirectly via the vanish
    // path with a name we never connected to.  The handler's first guard
    // (`name.startsWith(MPRIS_PREFIX)`) passes; the second
    // (`name == m_activeService`) fails because m_activeService is empty,
    // so we hit no branch.  No emissions, no crash.
    invokeSlot(&m,
               "handleNameOwnerChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.notconnected"),
               Q_ARG(QString, ":1.42"),
               Q_ARG(QString, ""));
    QCOMPARE(enabledSpy.count(), 0);
}

void TestMprisColors::handleNameOwnerChanged_alreadyConnectedIgnoresOther() {
    MprisMonitor m;
    if (! injectFakeService(m, "org.mpris.MediaPlayer2.first"))
        QSKIP("could not establish an active MPRIS service for this test");
    QString first = m.activeService();

    QSignalSpy enabledSpy(&m, &MprisMonitor::enabledChanged);
    // A second MPRIS service appearing while we already have one MUST NOT
    // switch — the connect branch is gated on `m_activeService.isEmpty()`.
    invokeSlot(&m,
               "handleNameOwnerChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.second"),
               Q_ARG(QString, ""),
               Q_ARG(QString, ":1.50"));
    QCOMPARE(m.activeService(), first); // unchanged
    QCOMPARE(enabledSpy.count(), 0);    // no enabledChanged
}

void TestMprisColors::handlePropsChanged_changedArtUrl_reprocesses() {
    QTemporaryFile a, b;
    a.setAutoRemove(true);
    b.setAutoRemove(true);
    QVERIFY(a.open());
    QVERIFY(b.open());
    QImage imgA(8, 8, QImage::Format_RGB32);
    imgA.fill(Qt::yellow);
    QImage imgB(8, 8, QImage::Format_RGB32);
    imgB.fill(Qt::darkBlue);
    QVERIFY(imgA.save(a.fileName(), "PNG"));
    QVERIFY(imgB.save(b.fileName(), "PNG"));
    a.close();
    b.close();

    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::thumbnailChanged);

    QVariantMap c1;
    c1["Metadata"] = mkMeta("T", {}, 0, QUrl::fromLocalFile(a.fileName()).toString());
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c1),
               Q_ARG(QStringList, QStringList()));
    QVERIFY(spy.wait(5000)); // let cover A's emit land before dispatching B
    QCOMPARE(spy.count(), 1);

    // Different artUrl → must reprocess and re-emit, even though we already
    // have a thumbnail.
    QVariantMap c2;
    c2["Metadata"] = mkMeta("T", {}, 0, QUrl::fromLocalFile(b.fileName()).toString());
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c2),
               Q_ARG(QStringList, QStringList()));
    QTRY_COMPARE_WITH_TIMEOUT(spy.count(), 2, 5000);
    QCOMPARE(spy.at(1).at(0).toBool(), true);
}

void TestMprisColors::shortcut_canonicalNamesAlsoMap() {
    // mapShortcutToMpris also accepts non-b-prefixed canonical names —
    // covers the second alternative on each branch.
    QCOMPARE(wekde::mapShortcutToMpris("play"), QString("Play"));
    QCOMPARE(wekde::mapShortcutToMpris("playpause"), QString("PlayPause"));
    QCOMPARE(wekde::mapShortcutToMpris("stop"), QString("Stop"));
    QCOMPARE(wekde::mapShortcutToMpris("next"), QString("Next"));
    QCOMPARE(wekde::mapShortcutToMpris("previous"), QString("Previous"));
    QCOMPARE(wekde::mapShortcutToMpris("bplayonly"), QString("Play"));
}

// ===========================================================================
// Live-DBus driver — FakeMprisService
// ===========================================================================
//
// Registers a real org.mpris.MediaPlayer2.testfake.* service on the session
// bus and answers the org.freedesktop.DBus.Properties.Get method with
// canned PlaybackStatus / Metadata / Position values.  This drives the
// findActivePlayer → connectToPlayer → fetchAllProperties code path that
// the no-injection unit tests above can't reach.
class FakeMprisService : public QDBusVirtualObject {
public:
    explicit FakeMprisService(const QString& playbackStatus = "Playing", qint64 lengthUs = 0,
                              qint64 positionUs = 0, const QString& title = "Fake Track",
                              const QStringList& artists = { "Fake Artist" })
        : m_playbackStatus(playbackStatus),
          m_lengthUs(lengthUs),
          m_positionUs(positionUs),
          m_title(title),
          m_artists(artists) {}

    QString introspect(const QString& /*path*/) const override {
        return QStringLiteral("<interface name=\"org.freedesktop.DBus.Properties\">"
                              "  <method name=\"Get\">"
                              "    <arg direction=\"in\"  name=\"interface\" type=\"s\"/>"
                              "    <arg direction=\"in\"  name=\"name\"      type=\"s\"/>"
                              "    <arg direction=\"out\" name=\"value\"     type=\"v\"/>"
                              "  </method>"
                              "</interface>");
    }

    bool handleMessage(const QDBusMessage& message, const QDBusConnection& connection) override {
        if (message.interface() != "org.freedesktop.DBus.Properties") return false;
        if (message.member() != "Get") return false;
        if (message.arguments().size() < 2) return false;

        const QString prop = message.arguments().at(1).toString();
        QVariant      value;
        if (prop == "PlaybackStatus") {
            value = QVariant::fromValue(m_playbackStatus);
        } else if (prop == "Position") {
            value = QVariant::fromValue<qlonglong>(m_positionUs);
        } else if (prop == "Metadata") {
            QVariantMap meta;
            meta["xesam:title"]  = m_title;
            meta["xesam:artist"] = m_artists;
            meta["mpris:length"] = QVariant::fromValue<qlonglong>(m_lengthUs);
            meta["mpris:artUrl"] = QString(); // empty → Empty kind in classifyArtUrl
            value                = meta;
        } else {
            return false;
        }

        QDBusMessage reply = message.createReply(QVariant::fromValue(QDBusVariant(value)));
        return connection.send(reply);
    }

private:
    QString     m_playbackStatus;
    qint64      m_lengthUs;
    qint64      m_positionUs;
    QString     m_title;
    QStringList m_artists;
};

// RAII helper: registers a unique service+object on the session bus and
// unregisters it on destruction.  Returns the registered service name.
class FakeMprisRegistration {
public:
    FakeMprisRegistration(FakeMprisService* svc, const QString& nameSuffix)
        : m_bus(QDBusConnection::sessionBus()),
          m_serviceName("org.mpris.MediaPlayer2.testfake_" + nameSuffix) {
        if (! m_bus.isConnected()) return;
        m_objectRegistered  = m_bus.registerVirtualObject("/org/mpris/MediaPlayer2", svc);
        m_serviceRegistered = m_bus.registerService(m_serviceName);
    }
    ~FakeMprisRegistration() {
        if (m_serviceRegistered) m_bus.unregisterService(m_serviceName);
        if (m_objectRegistered) m_bus.unregisterObject("/org/mpris/MediaPlayer2");
    }
    bool    ok() const { return m_serviceRegistered && m_objectRegistered; }
    QString serviceName() const { return m_serviceName; }

private:
    QDBusConnection m_bus;
    QString         m_serviceName;
    bool            m_objectRegistered { false };
    bool            m_serviceRegistered { false };
};

void TestMprisColors::findActivePlayer_picksUpRegisteredFakeService() {
    FakeMprisService svc(
        "Playing", /*lengthUs=*/120'000'000, /*positionUs=*/5'000'000, "Hello", { "World" });
    FakeMprisRegistration reg(&svc, "find_active");
    if (! reg.ok()) QSKIP("session bus or service registration unavailable in this env");

    // engage() lazily wires up the D-Bus watch and runs findActivePlayer(),
    // which asynchronously runs ListNames, finds our fake, fires an async
    // Get(PlaybackStatus), sees "Playing", picks it, then calls connectToPlayer()
    // → fetchAllProperties() (the latter exercises the async PlaybackStatus /
    // Metadata / Position valid-reply branches).  Because the scan is now async
    // (QDBusConnection::asyncCall + QDBusPendingCallWatcher — so a hung player
    // can't block the GUI thread), the result lands on a later event-loop turn,
    // so we spin the loop until activeService() resolves before asserting.
    MprisMonitor m;
    m.engage();

    // Pump the event loop so the async ListNames + per-service PlaybackStatus
    // replies are delivered.  Bounded wait; loopback DBus resolves in well
    // under this budget. Spin-don't-wait via QTRY: exits as soon as the scan
    // resolves instead of polling for the full 2s budget.
    QTRY_VERIFY_WITH_TIMEOUT(! m.activeService().isEmpty(), 2000);

    // If the bus arbitrarily picked another already-registered MPRIS player
    // (rare in test env but possible), our fake's Get won't have been
    // selected.  Skip rather than fail.
    if (m.activeService() != reg.serviceName()) {
        QSKIP("session bus already has another MPRIS player; fake not selected");
    }

    // The async findActivePlayer→connectToPlayer success path ran for our fake
    // (ListNames → async Get(PlaybackStatus) → "Playing" → connectToPlayer →
    // fetchAllProperties' async PlaybackStatus/Metadata/Position reads).  The
    // next test re-uses the same kind of fake to drive Position separately and
    // observe a signal.
    QCOMPARE(m.activeService(), reg.serviceName());
}

void TestMprisColors::pollPosition_withActiveService_emitsTimeline() {
    FakeMprisService      svc("Paused", /*lengthUs=*/30'000'000, /*positionUs=*/12'500'000);
    FakeMprisRegistration reg(&svc, "pollpos");
    if (! reg.ok()) QSKIP("session bus or service registration unavailable in this env");

    MprisMonitor m;
    m.engage();
    // findActivePlayer is async now — spin-don't-wait via QTRY until it
    // connects. Exits as soon as activeService resolves; busy-wait version
    // polled at 20ms intervals up to 2s even when DBus answered in <50ms.
    QTRY_VERIFY_WITH_TIMEOUT(! m.activeService().isEmpty(), 2000);
    if (m.activeService() != reg.serviceName())
        QSKIP("session bus already has another MPRIS player; fake not selected");

    QSignalSpy spy(&m, &MprisMonitor::timelineChanged);
    // Re-trigger pollPosition; the slot now fires an async Get(Position) and
    // emits timelineChanged from the reply watcher on a later event-loop turn,
    // so we wait for the signal rather than asserting synchronously.
    invokeSlot(&m, "pollPosition");
    QVERIFY(spy.wait(2000));
    QCOMPARE(spy.last().at(0).toDouble(), 12.5);
    QCOMPARE(spy.last().at(1).toDouble(), 30.0);
}

void TestMprisColors::handlePropsChanged_metadataAsQDBusArgument_unmarshalsAndEmits() {
    // Drive the `v.canConvert<QDBusArgument>()` true branch (line 237-239
    // of MprisMonitor.cpp).  Building a fully-readable QDBusArgument
    // outside a real bus round-trip is not portable — Qt marks freshly-
    // streamed QDBusArguments as write-only, and qdbus_cast returns empty
    // when reading from one.  That's fine for *coverage* (the line
    // executes); we then fall through to `meta = v.toMap()` which also
    // returns empty for a QDBusArgument variant, and parseMprisMetadata
    // emits a propertiesChanged with default-constructed strings.  All we
    // need to assert is that the slot ran the canConvert branch and did
    // not crash.
    QDBusArgument arg;
    arg.beginMap(QMetaType::fromType<QString>(), QMetaType::fromType<QDBusVariant>());
    arg.beginMapEntry();
    arg << QString("xesam:title");
    arg << QDBusVariant(QString("DbusArgTitle"));
    arg.endMapEntry();
    arg.endMap();

    MprisMonitor m;
    QSignalSpy   propsSpy(&m, &MprisMonitor::propertiesChanged);
    QVariantMap  changed;
    changed["Metadata"] = QVariant::fromValue(arg);
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, changed),
               Q_ARG(QStringList, QStringList()));
    // The slot ran the canConvert branch (line 238) and reached the emit.
    QCOMPARE(propsSpy.count(), 1);
}

// ----- HTTP path: real local QTcpServer that returns a valid PNG --------
class FakePngHttpServer : public QObject {
public:
    explicit FakePngHttpServer(const QByteArray& body): m_body(body) {
        connect(&m_server, &QTcpServer::newConnection, this, [this] {
            QTcpSocket* sock = m_server.nextPendingConnection();
            connect(sock, &QTcpSocket::readyRead, this, [this, sock] {
                writeReply(sock);
            });
            connect(sock, &QTcpSocket::disconnected, sock, &QObject::deleteLater);
        });
    }
    bool    listen() { return m_server.listen(QHostAddress::LocalHost, 0); }
    quint16 port() const { return m_server.serverPort(); }

private:
    void writeReply(QTcpSocket* sock) {
        // Read & ignore the request line(s) — we always reply the same body.
        sock->readAll();
        QByteArray header;
        header += "HTTP/1.0 200 OK\r\n";
        header += "Content-Type: image/png\r\n";
        header += "Content-Length: " + QByteArray::number(m_body.size()) + "\r\n";
        header += "Connection: close\r\n\r\n";
        sock->write(header);
        sock->write(m_body);
        sock->flush();
        sock->disconnectFromHost();
    }
    QTcpServer m_server;
    QByteArray m_body;
};

void TestMprisColors::processArtUrl_http_downloadsAndEmits() {
    // Build a real PNG so decodeArtReplyBytes succeeds end-to-end.
    QImage img(8, 8, QImage::Format_RGB32);
    img.fill(QColor(255, 64, 32));
    QByteArray bytes;
    {
        QBuffer buf(&bytes);
        buf.open(QIODevice::WriteOnly);
        QVERIFY(img.save(&buf, "PNG"));
    }

    FakePngHttpServer server(bytes);
    QVERIFY(server.listen());

    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::thumbnailChanged);

    QVariantMap c;
    c["Metadata"] = mkMeta("T", {}, 0, QString("http://127.0.0.1:%1/cover.png").arg(server.port()));
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));

    // Wait for the network request to complete and onArtDownloaded to fire.
    // 5 second budget — local loopback should resolve in well under 100 ms.
    QVERIFY(spy.wait(5000));
    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.last().at(0).toBool(), true);
    QCOMPARE(spy.last().at(1).toList().size(), 15);
}

void TestMprisColors::processArtUrl_http_returnsGarbage_emitsThumbnailFalse() {
    // Server replies with non-PNG bytes — exercise the decode-failure
    // branch of onArtDownloaded (lines 318-319 of MprisMonitor.cpp:
    // `emit thumbnailChanged(false, {});` when decodeArtReplyBytes returns
    // false even after a successful HTTP fetch).
    QByteArray junk("\x00\xFF this is definitely not an image \x42", 36);

    FakePngHttpServer server(junk);
    QVERIFY(server.listen());

    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::thumbnailChanged);

    QVariantMap c;
    c["Metadata"] =
        mkMeta("T", {}, 0, QString("http://127.0.0.1:%1/garbage.png").arg(server.port()));
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));

    QVERIFY(spy.wait(5000));
    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.last().at(0).toBool(), false);
}

// Reach the private helper that previous tests used. Defined here so
// these later tests can call it too.
extern bool injectFakeService(MprisMonitor& m, const QString& service);

void TestMprisColors::classifyArtUrl_dataUrl_isUnknown() {
    // data: URLs (inline base64 image bodies — some MPRIS players emit
    // these) are neither LocalFile nor Http; the helper must fall into
    // Unknown so the caller knows not to try a file fetch or HTTP GET.
    QCOMPARE(wekde::classifyArtUrl("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"),
             wekde::MprisArtUrlKind::Unknown);
    // Also: bare data: prefix and weird schemes round-trip safely.
    QCOMPARE(wekde::classifyArtUrl("data:"), wekde::MprisArtUrlKind::Unknown);
    QCOMPARE(wekde::classifyArtUrl("blob:https://example.com/abc"),
             wekde::MprisArtUrlKind::Unknown);
}

void TestMprisColors::extractDominantColors_transparentImage() {
    // Fully transparent 32x32 ARGB image — every pixel alpha=0. The
    // color extractor must not crash on this (Mull-easy mutation
    // target: dropping an alpha-channel guard).
    QImage img(32, 32, QImage::Format_ARGB32);
    img.fill(QColor(0, 0, 0, 0));
    const auto colors = wekde::extractDominantColors(img);
    // Contract: returns 15 floats regardless of input. Values stay in
    // 0-1 range. We don't assert specific colors (a fully transparent
    // image has no meaningful dominant color; implementations may pick
    // black, transparent-as-black, or something else).
    QCOMPARE(colors.size(), 15);
    for (const auto& v : colors) {
        const double d = v.toDouble();
        QVERIFY(d >= 0.0 && d <= 1.0);
    }
}

void TestMprisColors::reconnect_sameArtUrl_doesNotDedup() {
    // Regression test for the m_artUrlEverProcessed reset bug:
    // connect → process art URL → disconnect → reconnect → SAME art URL
    // must process again (the previous-connection's dedup state must
    // not bleed into the new connection's state).
    MprisMonitor m;
    if (! injectFakeService(m, "org.mpris.MediaPlayer2.first"))
        QSKIP("could not establish an active MPRIS service for this test");

    QSignalSpy thumbSpy(&m, &MprisMonitor::thumbnailChanged);

    // Write a tiny PNG to disk for the LocalFile path.
    QTemporaryFile cover;
    cover.setAutoRemove(true);
    QVERIFY(cover.open());
    QImage img(8, 8, QImage::Format_RGB32);
    img.fill(Qt::yellow);
    QVERIFY(img.save(cover.fileName(), "PNG"));
    cover.close();
    const QString artUrl = "file://" + cover.fileName();

    // Process the art URL once via handlePropertiesChanged. m_lastArtUrl
    // + m_artUrlEverProcessed get populated.
    QVariantMap c;
    QVariantMap meta;
    meta["mpris:artUrl"] = artUrl;
    c["Metadata"]        = meta;
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QVERIFY(thumbSpy.wait(5000)); // first (async) decode emit
    const int firstCount = thumbSpy.count();
    QVERIFY(firstCount >= 1);

    // Disconnect — the fix resets m_lastArtUrl + m_artUrlEverProcessed.
    invokeSlot(&m,
               "handleNameOwnerChanged",
               Q_ARG(QString, m.activeService()),
               Q_ARG(QString, ":1.42"),
               Q_ARG(QString, ""));
    QCOMPARE(m.activeService(), QString(""));

    // Reconnect — same artUrl re-processes (no silent dedup).
    if (! injectFakeService(m, "org.mpris.MediaPlayer2.second"))
        QSKIP("could not establish a second MPRIS service for this test");
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QTRY_VERIFY_WITH_TIMEOUT(thumbSpy.count() > firstCount, 5000);
}

void TestMprisColors::processArtUrl_localFile_emitsThumbnailAsync() {
    QTemporaryFile f;
    f.setAutoRemove(true);
    QVERIFY(f.open());
    QImage img(16, 16, QImage::Format_RGB32);
    img.fill(Qt::magenta);
    QVERIFY(img.save(f.fileName(), "PNG"));
    f.close();

    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::thumbnailChanged);
    QVariantMap  c;
    c["Metadata"] = mkMeta("T", {}, 0, QUrl::fromLocalFile(f.fileName()).toString());
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QCOMPARE(spy.count(), 0); // proof it is async: not emitted synchronously
    QTRY_COMPARE_WITH_TIMEOUT(spy.count(), 1, 5000);
    QCOMPARE(spy.at(0).at(0).toBool(), true);
    QCOMPARE(spy.at(0).at(1).toList().size(), 15);
}

void TestMprisColors::processArtUrl_localFileMissing_emitsFalseAsync() {
    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::thumbnailChanged);
    QVariantMap  c;
    c["Metadata"] = mkMeta("T", {}, 0, "file:///tmp/wekde_nonexistent_cover_991.png");
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c),
               Q_ARG(QStringList, QStringList()));
    QCOMPARE(spy.count(), 0);
    QTRY_COMPARE_WITH_TIMEOUT(spy.count(), 1, 5000);
    QCOMPARE(spy.at(0).at(0).toBool(), false);
}

void TestMprisColors::processArtUrl_supersededLocalDecode_dropsStaleResult() {
    QTemporaryFile a, b;
    a.setAutoRemove(true);
    b.setAutoRemove(true);
    QVERIFY(a.open());
    QVERIFY(b.open());
    QImage imgA(16, 16, QImage::Format_RGB32);
    imgA.fill(Qt::red);
    QImage imgB(16, 16, QImage::Format_RGB32);
    imgB.fill(Qt::blue);
    QVERIFY(imgA.save(a.fileName(), "PNG"));
    QVERIFY(imgB.save(b.fileName(), "PNG"));
    a.close();
    b.close();

    MprisMonitor m;
    QSignalSpy   spy(&m, &MprisMonitor::thumbnailChanged);

    QVariantMap c1;
    c1["Metadata"] = mkMeta("A", {}, 0, QUrl::fromLocalFile(a.fileName()).toString());
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c1),
               Q_ARG(QStringList, QStringList()));
    // Immediately supersede with cover B before A's decode is guaranteed to land.
    QVariantMap c2;
    c2["Metadata"] = mkMeta("B", {}, 0, QUrl::fromLocalFile(b.fileName()).toString());
    invokeSlot(&m,
               "handlePropertiesChanged",
               Q_ARG(QString, "org.mpris.MediaPlayer2.Player"),
               Q_ARG(QVariantMap, c2),
               Q_ARG(QStringList, QStringList()));

    // The guard guarantees the FINAL emitted state is B's (A's generation was
    // superseded). Can't pin intermediate emits (timing), but the last must be
    // hasThumbnail=true from the surviving generation. Spin-don't-wait via
    // QTRY for the surviving emit; qWait(300) "drain" pad burned 300ms even
    // when the emit landed in 5ms.
    QTRY_VERIFY_WITH_TIMEOUT(spy.count() >= 1 && spy.last().at(0).toBool(), 5000);
    QCOMPARE(spy.last().at(1).toList().size(), 15);
}

// Reconnecting to a different MPRIS player whose first reply happens to
// match the previous player's playback state must NOT be deduped by the
// applyPlaybackStatus `state != m_playbackState` guard. The disconnect
// path resets m_playbackState to -1 (a sentinel outside the 0/1/2 value
// space) so the first reply always emits — same shape as the art-URL
// dedup reset that landed earlier.
void TestMprisColors::disconnectFromPlayer_resetsDedupState_so_matchingFirstReplyEmits() {
    wekde::MprisMonitor mon;
    QSignalSpy          spy(&mon, &wekde::MprisMonitor::playbackStateChanged);

    // First connection: Playing.
    mon.applyPlaybackStatus(QStringLiteral("Playing"));
    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.last().at(0).toInt(), 1);

    // Player vanishes; disconnect path runs. Set a stub service so the
    // early-return guard at the top of disconnectFromPlayer passes (the
    // guard is only relevant for production where it avoids a redundant
    // D-Bus disconnect when there's nothing to disconnect from).
    mon.m_activeService = QStringLiteral("org.mpris.MediaPlayer2.test");
    mon.disconnectFromPlayer();

    // New player connects; first reply is also Playing — before the fix
    // this was swallowed by the `state != m_playbackState` guard.
    mon.applyPlaybackStatus(QStringLiteral("Playing"));
    QCOMPARE(spy.count(), 2);
    QCOMPARE(spy.last().at(0).toInt(), 1);
}

QTEST_GUILESS_MAIN(TestMprisColors)
#include "tst_mpriscolors.moc"
